Skip to content

fix: address MEDIUM review findings (light witness resilience, proxy CORS, threshold warning) - #1382

Open
lklimek wants to merge 4 commits into
v1.6-devfrom
fix/review-medium-findings
Open

fix: address MEDIUM review findings (light witness resilience, proxy CORS, threshold warning)#1382
lklimek wants to merge 4 commits into
v1.6-devfrom
fix/review-medium-findings

Conversation

@lklimek

@lklimek lklimek commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Why this PR exists

  • Problem: A code review of the v1.5.4 → v1.6.0 diff surfaced three MEDIUM-severity issues in the light client and consensus state machine that degrade resilience and safety posture.
  • What breaks without it: (1) a single faulty or partitioned witness can abort the entire light client (ErrConflictingWitnessHeader), breaking liveness for honest users; (2) the light proxy accepts WebSocket connections from any origin, enabling cross-site subscription to consensus streams; (3) running below the 2/3+1 voting-power threshold proceeds silently with no operator warning that safety is no longer guaranteed.
  • Blocking relationship: none — standalone fix branch based on v1.6-dev.

Issue being fixed or feature implemented

Addresses three review findings:

  • CODE-001 (resilience): tolerate one dissenting witness instead of failing the client.
  • SEC-001 (security): enforce WebSocket origin checking on the light proxy by default.
  • SEC-002 (safety): warn explicitly when voting_power_threshold drops below 2/3+1.

What was done?

  • light/client.go: single conflicting witness (with ≥1 corroborating) is removed from the trusted set; ≥2 conflicting witnesses, or a conflict with zero corroboration, still fail closed.
  • light/proxy/proxy.go: replaced the permissive func(*http.Request) bool { return true } origin check with rpcserver.OriginChecker over a new AllowedOrigins field (zero-trust default: empty list rejects all). cmd/tenderdash/commands/light.go wires conf.RPC.CORSAllowedOrigins into the proxy.
  • internal/consensus/state_data.go: logs an explicit OVERRIDE ENABLED - NOT SAFE FOR PRODUCTION error when validators.BelowStrictThreshold() is true.
  • CHANGELOG.md + docs/nodes/configuration.md: document the behavior changes.

How Has This Been Tested?

  • gofmt clean on all changed files.
  • go vet ./light/ ./light/proxy/ ./internal/consensus/ clean (BLS native deps built via make build-bls).
  • go test ./light/ passes (13.4s), including updated TestCompareFirstHeaderWithWitness* cases: single conflicting witness → removed (no error), two conflicting → fail, single conflicting with no corroboration → fail.
  • go build ./light/proxy/ ./cmd/tenderdash/commands/ succeeds.

Breaking Changes

None in the Go API. Behavioral: the light proxy now rejects WebSocket connections from unconfigured origins by default (previously accepted all). Operators relying on the open behavior must set rpc.cors-allowed-origins.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 3 commits July 8, 2026 11:56
…he client

Previously any single witness returning a first/new header that disagreed with
the primary would abort the light client with ErrConflictingWitnessHeader,
letting one faulty or partitioned witness break liveness for the whole client.

Now a single conflicting witness (with at least one corroborating witness) is
removed from the trusted set, while two or more conflicting witnesses, or a
conflict with no corroboration, still fail closed. This prevents one bad witness
from taking down the client while preserving safety against genuine forks.

Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
…trust)

The light proxy previously accepted WebSocket connections from any origin
(func(*http.Request) bool { return true }), allowing cross-site clients to
subscribe to consensus/block streams.

The proxy now uses rpcserver.OriginChecker against the configured
rpc.cors-allowed-origins list, defaulting to an empty list (reject all) for a
zero-trust posture. Operators opt in by setting cors-allowed-origins in config.

Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
…2/3+1

When validators.BelowStrictThreshold() is true the node can no longer
guarantee safety, yet previously continued silently. The state machine now logs
an explicit "OVERRIDE ENABLED - NOT SAFE FOR PRODUCTION" error so operators are
aware they are running an unsafe override.

Also documents the behavior change and that cors-allowed-origins governs the
light proxy.

Co-Authored-By: Claudius the Magnificent <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: befd164f-c189-42ad-9e61-a87ba34c7448

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/review-medium-findings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lklimek
lklimek marked this pull request as ready for review July 30, 2026 08:04
@lklimek
lklimek requested a review from QuantumExplorer as a code owner July 30, 2026 08:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses three MEDIUM-severity review findings by improving light client resilience to faulty witnesses, tightening the light proxy’s WebSocket origin policy, and making unsafe validator-threshold overrides explicitly visible to operators.

Changes:

  • Light client: tolerate a single conflicting witness when the primary is corroborated (remove the dissenting witness instead of failing).
  • Light proxy: switch WebSocket origin checks from allow-all to rpcserver.OriginChecker driven by configured allowed origins.
  • Consensus: emit a prominent log when the validator voting threshold is below the strict 2/3+1 safety floor, plus document behavior changes.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
light/proxy/proxy.go Enforces a stricter default WebSocket origin policy using rpcserver.OriginChecker and a new AllowedOrigins field.
cmd/tenderdash/commands/light.go Wires rpc.cors-allowed-origins into the light proxy’s WebSocket origin allow-list.
light/client.go Updates witness cross-check logic to remove a single dissenting witness when corroboration exists; fail closed on stronger fork evidence.
light/detector_internal_test.go Expands test cases to reflect new “single dissenting witness removed” and “multiple dissenting witnesses fail” behavior.
light/client_test.go Updates client behavior test to expect removal (not failure) for a single conflicting witness with corroboration.
internal/consensus/state_data.go Promotes below-threshold condition to a prominent log message for operator visibility.
docs/nodes/configuration.md Documents that the RPC CORS origins list also governs light proxy WebSocket origin policy.
CHANGELOG.md Records the behavioral changes (proxy origin policy, witness handling, below-threshold warning).

Comment thread light/client.go
Comment thread internal/consensus/state_data.go

@lklimek lklimek left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@thepastaclaw

thepastaclaw commented Jul 30, 2026

Copy link
Copy Markdown

⛔ Blockers found — Sonnet deferred (commit f4507f5)
Canonical validated blockers: 3

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preliminary review — Codex only

The light-client witness change introduces two blocking defects: it weakens the documented any-honest-witness security model and can panic when bad and conflicting witnesses appear together. The WebSocket policy remains bypassable through DNS rebinding, and the validator-threshold warning can both miss unsafe transitions and falsely claim that a canonical threshold is an override. These issues require changes before merge.

Source: reviewers codex/general=gpt-5.6-sol(completed); codex/tenderdash-consensus-security=gpt-5.6-sol(completed); verifier=codex/verifier=gpt-5.6-sol(completed); coordinator=openclaw-agent/cliproxy/gpt-5.6-sol(orchestration-only).

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `light/client.go`:
- [BLOCKING] light/client.go:888-912: A colluding primary and witness can evict the sole honest witness
  This changes the light client from the documented any-honest-witness model to an unenforced provider-majority model. If a malicious primary serves a validly signed non-canonical fork, one colluding witness returns the same header, and the sole honest witness returns the canonical header, the counters are one corroborating and one conflicting witness. The code removes the honest witness and then persists the primary's fork. Witness comparison exists specifically to detect an otherwise valid conflicting fork after validator trust assumptions have failed, and `docs/tendermint-core/light-client.md` currently says the client is unsafe only when all witnesses are malicious. Fail closed when both sides provide conflicting valid headers, or explicitly adopt, document, and enforce a suitable honest-majority provider model rather than automatically selecting the primary's side.
- [BLOCKING] light/client.go:883-909: Sequential removals use stale witness indices and can panic
  `conflictingWitnesses` stores indices from the original witness slice, but `removeWitnesses(witnessesToRemove)` first reorders and truncates that slice using swap-with-last. With witnesses `[matching, bad, conflicting]`, removing bad index 1 moves the conflict to index 1 and leaves a two-element slice; the subsequent log evaluates `c.witnesses[2]` and panics. Other orderings can remove the wrong provider instead. Do not mutate `c.witnesses` until every provider selected for removal has been captured; if dissent removal remains after resolving the security issue above, combine all indices into one removal operation and add a regression test containing matching, bad, and conflicting witnesses together.

In `light/proxy/proxy.go`:
- [BLOCKING] light/proxy/proxy.go:123: Same-host fallback permits DNS-rebinding WebSocket access
  `OriginChecker` trusts the request's unvalidated `Host` header whenever it matches the Origin host, even when the configured allow-list is empty. An attacker can serve a page from `http://evil.example:8888`, rebind that hostname to the victim's loopback or private address, and connect to `ws://evil.example:8888/websocket`. The browser then sends both `Origin: http://evil.example:8888` and `Host: evil.example:8888`, so the check succeeds against the default localhost light proxy. This exposes WebSocket RPC methods, including transaction broadcasting and subscriptions, despite the claimed zero-trust default. Reject Origin-bearing requests unless the origin is explicitly configured, or independently validate `Host` against trusted listener or reverse-proxy hostnames.

In `internal/consensus/state_data.go`:
- [SUGGESTION] internal/consensus/state_data.go:269-280: Unsafe-threshold warning can be skipped and mislabels canonical quorums
  The outer condition does not account for changes to quorum type or total validator power, although both affect `BelowStrictThreshold()`. For example, a same-quorum set with an unchanged explicit threshold of 800 can grow from 11 validators to 12: the strict floor rises from 734 to 801, making the configuration unsafe, but the quorum hash and raw threshold remain unchanged and no error is logged. The message is also factually wrong for canonical thresholds: `DEVNET_PLATFORM` with 12 validators derives an 800 threshold while `VotingPowerThreshold == 0`, so it is below the 801 strict floor without any override. Trigger the check whenever the effective safety inputs change and add the `OVERRIDE ENABLED` prefix only for an explicit override.

Comment thread light/client.go
Comment on lines +888 to +912
// A single conflicting witness must not be allowed to break verification: a
// lone dissenter is far more likely to be a faulty/compromised witness than a
// sign that the primary is malicious, and failing here would let one bad
// witness denial-of-service an otherwise-healthy light client. We therefore
// treat a single conflicting witness as faulty and remove it, exactly like
// errBadWitness. We still FAIL (preserving the safety-over-availability
// guarantee) only when the conflict is corroborated:
// - two or more witnesses disagree with the primary (genuine fork / primary
// likely faulty), OR
// - at least one witness conflicts and NO witness corroborates the primary
// (we cannot establish trust in either party, so we refuse to proceed).
if len(conflictingWitnesses) > 1 || (len(conflictingWitnesses) == 1 && corroborating == 0) {
return fmt.Errorf("%w: witnesses %v", ErrConflictingWitnessHeader, conflictingWitnesses)
}

// Exactly one conflicting witness with at least one corroborating witness:
// drop the dissenter and continue.
if len(conflictingWitnesses) == 1 {
c.logger.Warn("single witness conflicts with the primary but others corroborate it; "+
"removing the dissenting witness rather than failing verification",
"witness", c.witnesses[conflictingWitnesses[0]])
if err := c.removeWitnesses(conflictingWitnesses); err != nil {
return err
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: A colluding primary and witness can evict the sole honest witness

This changes the light client from the documented any-honest-witness model to an unenforced provider-majority model. If a malicious primary serves a validly signed non-canonical fork, one colluding witness returns the same header, and the sole honest witness returns the canonical header, the counters are one corroborating and one conflicting witness. The code removes the honest witness and then persists the primary's fork. Witness comparison exists specifically to detect an otherwise valid conflicting fork after validator trust assumptions have failed, and docs/tendermint-core/light-client.md currently says the client is unsafe only when all witnesses are malicious. Fail closed when both sides provide conflicting valid headers, or explicitly adopt, document, and enforce a suitable honest-majority provider model rather than automatically selecting the primary's side.

source: ['codex']

Comment thread light/client.go
Comment on lines 885 to +909
return err
}

// A witness presenting a header that conflicts with the primary's means the
// primary's header cannot be trusted: fail the comparison.
if len(conflictingWitnesses) > 0 {
// A single conflicting witness must not be allowed to break verification: a
// lone dissenter is far more likely to be a faulty/compromised witness than a
// sign that the primary is malicious, and failing here would let one bad
// witness denial-of-service an otherwise-healthy light client. We therefore
// treat a single conflicting witness as faulty and remove it, exactly like
// errBadWitness. We still FAIL (preserving the safety-over-availability
// guarantee) only when the conflict is corroborated:
// - two or more witnesses disagree with the primary (genuine fork / primary
// likely faulty), OR
// - at least one witness conflicts and NO witness corroborates the primary
// (we cannot establish trust in either party, so we refuse to proceed).
if len(conflictingWitnesses) > 1 || (len(conflictingWitnesses) == 1 && corroborating == 0) {
return fmt.Errorf("%w: witnesses %v", ErrConflictingWitnessHeader, conflictingWitnesses)
}

// Exactly one conflicting witness with at least one corroborating witness:
// drop the dissenter and continue.
if len(conflictingWitnesses) == 1 {
c.logger.Warn("single witness conflicts with the primary but others corroborate it; "+
"removing the dissenting witness rather than failing verification",
"witness", c.witnesses[conflictingWitnesses[0]])
if err := c.removeWitnesses(conflictingWitnesses); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Sequential removals use stale witness indices and can panic

conflictingWitnesses stores indices from the original witness slice, but removeWitnesses(witnessesToRemove) first reorders and truncates that slice using swap-with-last. With witnesses [matching, bad, conflicting], removing bad index 1 moves the conflict to index 1 and leaves a two-element slice; the subsequent log evaluates c.witnesses[2] and panics. Other orderings can remove the wrong provider instead. Do not mutate c.witnesses until every provider selected for removal has been captured; if dissent removal remains after resolving the security issue above, combine all indices into one removal operation and add a regression test containing matching, bad, and conflicting witnesses together.

source: ['codex']

Comment thread light/proxy/proxy.go
// permit specific browser origins set Proxy.AllowedOrigins (mirrors
// rpc.Config.CORSAllowedOrigins); a "*" entry allows any origin but is
// discouraged. This replaces the previous blanket allow-all policy.
wm.CheckOrigin = rpcserver.OriginChecker(p.Logger, p.AllowedOrigins)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Same-host fallback permits DNS-rebinding WebSocket access

OriginChecker trusts the request's unvalidated Host header whenever it matches the Origin host, even when the configured allow-list is empty. An attacker can serve a page from http://evil.example:8888, rebind that hostname to the victim's loopback or private address, and connect to ws://evil.example:8888/websocket. The browser then sends both Origin: http://evil.example:8888 and Host: evil.example:8888, so the check succeeds against the default localhost light proxy. This exposes WebSocket RPC methods, including transaction broadcasting and subscriptions, despite the claimed zero-trust default. Reject Origin-bearing requests unless the origin is explicitly configured, or independently validate Host against trusted listener or reverse-proxy hostnames.

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants